Form Validation in Flutter
Form validation is the process of checking whether the data entered by a user is correct, complete, and follows the required rules before the application accepts or processes it. Flutter provides built-in widgets such as Form, FormField, and TextFormField to create and validate forms efficiently.
Flutter's Form widget groups multiple form fields, while FormState.validate() validates the descendant form fields. A field's validator returns an error message when the value is invalid and null when the value is valid.
1. Why Form Validation Is Important
Applications frequently collect information such as names, email addresses, passwords, phone numbers, dates, and other user details. Validation helps ensure that the entered information meets the expected requirements.
- Prevents empty or incomplete fields.
- Checks whether data follows a required format.
- Improves the user experience by showing meaningful error messages.
- Reduces invalid data being submitted to a server or database.
- Helps maintain consistent application data.
- Provides immediate feedback to users.
2. Important Flutter Widgets Used for Validation
| Widget/Class | Purpose |
|---|
Form | Groups multiple form fields and provides form-level operations. |
FormField | Represents a single form field and manages its state and validation. |
TextFormField | Provides a text input field integrated with Form and validation. |
GlobalKey | Provides access to the form state so the form can be validated, saved, or reset. |
validator | Contains the validation rules for a form field. |
FormState | Provides methods such as validate(), save(), and reset(). |
AutovalidateMode | Controls when validation errors are automatically displayed. |
3. Basic Form Validation Flow
- Create a
Form.
- Create a
GlobalKey.
- Assign the key to the
Form.
- Add one or more
TextFormField widgets.
- Add validation logic using the
validator property.
- Call
_formKey.currentState!.validate() when the user submits the form.
- Process the data only when validation returns
true.
4. Creating a GlobalKey for Form Validation
A GlobalKey can be used to access the state of a form. It allows the application to call methods such as validate(), save(), and reset().
final _formKey = GlobalKey();
The key is then assigned to the Form widget:
Form(
key: _formKey,
child: Column(
children: [
// Form fields
],
),
)
5. TextFormField
TextFormField is commonly used when a text input needs to participate in form validation. It combines text input functionality with FormField behavior.
TextFormField(
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
)
6. The validator Property
The validator function contains the rules used to check the value entered into a field.
A validator should return an error message when the value is invalid and null when the value is valid.
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please enter your name';
}
return null;
},
)
How It Works
value contains the current input.
- If the value is invalid, return a descriptive error message.
- If the value is valid, return
null.
7. Required Field Validation
The simplest validation rule checks whether the user has entered a value.
TextFormField(
decoration: const InputDecoration(
labelText: 'Username',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Username is required';
}
return null;
},
)
Using trim() helps prevent input containing only spaces from being accepted.
8. Minimum Length Validation
Minimum-length validation is useful for passwords, usernames, addresses, and other fields where a certain amount of information is required.
TextFormField(
decoration: const InputDecoration(
labelText: 'Username',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Username is required';
}
if (value.trim().length < 4) {
return 'Username must contain at least 4 characters';
}
return null;
},
)
9. Maximum Length Validation
Maximum length validation prevents users from entering excessively long values.
TextFormField(
maxLength: 20,
decoration: const InputDecoration(
labelText: 'Username',
),
validator: (value) {
if (value != null && value.length > 20) {
return 'Username cannot exceed 20 characters';
}
return null;
},
)
10. Email Validation
Email validation checks whether the entered value follows a reasonable email structure.
TextFormField(
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required';
}
final email = value.trim();
final emailPattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!emailPattern.hasMatch(email)) {
return 'Enter a valid email address';
}
return null;
},
)
Client-side email validation should be treated as a basic format check. A backend should perform its own validation and business-rule checks.
11. Password Validation
Password validation can check minimum length and other requirements.
TextFormField(
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must contain at least 8 characters';
}
return null;
},
)
Password Validation with Multiple Rules
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must contain at least 8 characters';
}
if (!RegExp(r'[A-Z]').hasMatch(value)) {
return 'Password must contain an uppercase letter';
}
if (!RegExp(r'[0-9]').hasMatch(value)) {
return 'Password must contain a number';
}
return null;
}
12. Confirm Password Validation
A confirm-password field should match the original password.
final passwordController = TextEditingController();
TextFormField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must contain at least 8 characters';
}
return null;
},
),
TextFormField(
obscureText: true,
decoration: const InputDecoration(
labelText: 'Confirm Password',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please confirm your password';
}
if (value != passwordController.text) {
return 'Passwords do not match';
}
return null;
},
)
13. Phone Number Validation
A phone number can be validated according to the format required by the application.
TextFormField(
keyboardType: TextInputType.phone,
decoration: const InputDecoration(
labelText: 'Phone Number',
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Phone number is required';
}
final phone = value.trim();
if (!RegExp(r'^[0-9]{10}$').hasMatch(phone)) {
return 'Enter a valid 10-digit phone number';
}
return null;
},
)
14. Numeric Validation
Numeric validation can be used for fields such as age, quantity, price, or PIN values.
TextFormField(
keyboardType: TextInputType.number,
decoration: const InputDecoration(
labelText: 'Age',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Age is required';
}
final age = int.tryParse(value);
if (age == null) {
return 'Enter a valid number';
}
if (age < 18) {
return 'Age must be 18 or above';
}
return null;
},
)
15. Validating a Form on Submit
When the user presses the submit button, call validate() on the form state.
ElevatedButton(
onPressed: () {
if (_formKey.currentState!.validate()) {
print('Form is valid');
} else {
print('Form contains errors');
}
},
child: const Text('Submit'),
)
The validate() method validates every descendant FormField. It returns true when there are no validation errors and false when one or more fields are invalid.
16. Complete Basic Validation Example
import 'package:flutter/material.dart';
void main() {
runApp(const MyApp());
}
class MyApp extends StatelessWidget {
const MyApp({super.key});
@override
Widget build(BuildContext context) {
return MaterialApp(
debugShowCheckedModeBanner: false,
home: const RegistrationPage(),
);
}
}
class RegistrationPage extends StatefulWidget {
const RegistrationPage({super.key});
@override
State createState() => _RegistrationPageState();
}
class _RegistrationPageState extends State {
final _formKey = GlobalKey();
final nameController = TextEditingController();
final emailController = TextEditingController();
final passwordController = TextEditingController();
@override
void dispose() {
nameController.dispose();
emailController.dispose();
passwordController.dispose();
super.dispose();
}
void submitForm() {
if (_formKey.currentState!.validate()) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Form submitted successfully'),
),
);
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Registration'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: ListView(
children: [
TextFormField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Name is required';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required';
}
final email = value.trim();
final pattern =
RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!pattern.hasMatch(email)) {
return 'Enter a valid email';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must contain at least 8 characters';
}
return null;
},
),
const SizedBox(height: 20),
ElevatedButton(
onPressed: submitForm,
child: const Text('Register'),
),
],
),
),
),
);
}
}
17. AutovalidateMode
Flutter provides AutovalidateMode to control when validation happens automatically.
| Mode | Description |
|---|
AutovalidateMode.disabled | Automatic validation is disabled. |
AutovalidateMode.always | Validation runs automatically whenever the field is rebuilt. |
AutovalidateMode.onUserInteraction | Validation occurs after the user interacts with the field. |
Example
TextFormField(
autovalidateMode: AutovalidateMode.onUserInteraction,
decoration: const InputDecoration(
labelText: 'Email',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
return null;
},
)
18. Validating Multiple Fields
A single Form can contain multiple fields. Calling validate() validates all descendant form fields.
Form(
key: _formKey,
child: Column(
children: [
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'Name is required';
}
return null;
},
),
TextFormField(
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
return null;
},
),
TextFormField(
validator: (value) {
if (value == null || value.length < 8) {
return 'Password must contain at least 8 characters';
}
return null;
},
),
],
),
)
19. FormState Methods
| Method | Purpose |
|---|
validate() | Validates the form fields and returns a boolean result. |
save() | Calls the onSaved callback of descendant form fields. |
reset() | Resets form fields to their initial values and resets their validation state. |
validateGranularly() | Validates the form and returns the invalid form-field states. |
20. Using onSaved
The onSaved callback can be used to store validated field values when FormState.save() is called.
String name = '';
TextFormField(
decoration: const InputDecoration(
labelText: 'Name',
),
onSaved: (value) {
name = value ?? '';
},
)
Then save the form:
if (_formKey.currentState!.validate()) {
_formKey.currentState!.save();
print(name);
}
21. Resetting a Form
The reset() method restores form fields to their initial state.
ElevatedButton(
onPressed: () {
_formKey.currentState!.reset();
},
child: const Text('Reset'),
)
22. Using TextEditingController with Validation
TextEditingController is useful when the application needs to read, modify, or compare field values.
final emailController = TextEditingController();
TextFormField(
controller: emailController,
decoration: const InputDecoration(
labelText: 'Email',
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Email is required';
}
return null;
},
)
When controllers are created in a stateful widget, dispose of them when they are no longer needed.
@override
void dispose() {
emailController.dispose();
super.dispose();
}
23. Checkbox Validation
Some forms require the user to accept terms and conditions. A checkbox can be validated by maintaining its value in state.
bool acceptedTerms = false;
CheckboxListTile(
title: const Text('I accept the Terms and Conditions'),
value: acceptedTerms,
onChanged: (value) {
setState(() {
acceptedTerms = value ?? false;
});
},
)
Before submitting the form, check the checkbox value:
if (!acceptedTerms) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please accept the Terms and Conditions'),
),
);
return;
}
if (_formKey.currentState!.validate()) {
print('Form is valid');
}
24. Dropdown Validation
Dropdown form fields can also participate in form validation.
String? selectedRole;
DropdownButtonFormField(
value: selectedRole,
decoration: const InputDecoration(
labelText: 'Role',
border: OutlineInputBorder(),
),
items: const [
DropdownMenuItem(
value: 'student',
child: Text('Student'),
),
DropdownMenuItem(
value: 'developer',
child: Text('Developer'),
),
DropdownMenuItem(
value: 'designer',
child: Text('Designer'),
),
],
onChanged: (value) {
setState(() {
selectedRole = value;
});
},
validator: (value) {
if (value == null) {
return 'Please select a role';
}
return null;
},
)
25. Radio Button Validation
Radio buttons are useful when the user must choose one option from a group. The selected value can be checked before submission.
String? gender;
RadioListTile(
title: const Text('Male'),
value: 'male',
groupValue: gender,
onChanged: (value) {
setState(() {
gender = value;
});
},
)
RadioListTile(
title: const Text('Female'),
value: 'female',
groupValue: gender,
onChanged: (value) {
setState(() {
gender = value;
});
},
)
26. Date Validation
Date values can be validated according to application requirements. For example, an application can require a date to be selected before submission.
DateTime? selectedDate;
Future selectDate() async {
final picked = await showDatePicker(
context: context,
initialDate: DateTime.now(),
firstDate: DateTime(2000),
lastDate: DateTime(2100),
);
if (picked != null) {
setState(() {
selectedDate = picked;
});
}
}
Before submission:
if (selectedDate == null) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please select a date'),
),
);
return;
}
27. Creating Reusable Validators
When an application contains many forms, reusable validation functions can reduce duplicated code.
String? validateRequired(String? value, String fieldName) {
if (value == null || value.trim().isEmpty) {
return '$fieldName is required';
}
return null;
}
String? validateEmail(String? value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required';
}
final pattern = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!pattern.hasMatch(value.trim())) {
return 'Enter a valid email';
}
return null;
}
String? validatePassword(String? value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must contain at least 8 characters';
}
return null;
}
These validators can then be reused:
TextFormField(
validator: (value) {
return validateRequired(value, 'Name');
},
)
TextFormField(
validator: validateEmail,
)
TextFormField(
obscureText: true,
validator: validatePassword,
)
28. Combining Multiple Validation Rules
Multiple rules can be applied to the same field.
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Username is required';
}
final username = value.trim();
if (username.length < 4) {
return 'Username must contain at least 4 characters';
}
if (username.length > 20) {
return 'Username cannot exceed 20 characters';
}
if (!RegExp(r'^[a-zA-Z0-9_]+$').hasMatch(username)) {
return 'Only letters, numbers, and underscore are allowed';
}
return null;
}
29. Focus Management After Validation
For multi-field forms, FocusNode can be used to move the user's focus between fields.
final emailFocus = FocusNode();
TextFormField(
textInputAction: TextInputAction.next,
onFieldSubmitted: (_) {
FocusScope.of(context).requestFocus(emailFocus);
},
)
TextFormField(
focusNode: emailFocus,
keyboardType: TextInputType.emailAddress,
)
Focus nodes should be disposed when they are no longer needed.
@override
void dispose() {
emailFocus.dispose();
super.dispose();
}
30. Validation and Submission Flow
A typical form submission process can follow these steps:
- User enters information.
- Application performs client-side validation.
- Invalid fields display useful error messages.
- User corrects the invalid fields.
- The form is validated again.
- When validation succeeds, the application prepares the data.
- The application can then send the data to an API or save it locally.
- The server should perform its own validation before accepting or processing the data.
31. Example: Registration Form with Complete Validation
import 'package:flutter/material.dart';
class RegistrationForm extends StatefulWidget {
const RegistrationForm({super.key});
@override
State createState() => _RegistrationFormState();
}
class _RegistrationFormState extends State {
final _formKey = GlobalKey();
final nameController = TextEditingController();
final emailController = TextEditingController();
final passwordController = TextEditingController();
final confirmPasswordController = TextEditingController();
bool acceptedTerms = false;
@override
void dispose() {
nameController.dispose();
emailController.dispose();
passwordController.dispose();
confirmPasswordController.dispose();
super.dispose();
}
void submit() {
if (!acceptedTerms) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Please accept the Terms and Conditions'),
),
);
return;
}
if (!_formKey.currentState!.validate()) {
return;
}
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('Registration successful'),
),
);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: const Text('Create Account'),
),
body: Padding(
padding: const EdgeInsets.all(16),
child: Form(
key: _formKey,
child: ListView(
children: [
TextFormField(
controller: nameController,
decoration: const InputDecoration(
labelText: 'Full Name',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Full name is required';
}
if (value.trim().length < 3) {
return 'Name must contain at least 3 characters';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: emailController,
keyboardType: TextInputType.emailAddress,
decoration: const InputDecoration(
labelText: 'Email Address',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.trim().isEmpty) {
return 'Email is required';
}
final pattern =
RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
if (!pattern.hasMatch(value.trim())) {
return 'Enter a valid email address';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: passwordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Password',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Password is required';
}
if (value.length < 8) {
return 'Password must contain at least 8 characters';
}
return null;
},
),
const SizedBox(height: 16),
TextFormField(
controller: confirmPasswordController,
obscureText: true,
decoration: const InputDecoration(
labelText: 'Confirm Password',
border: OutlineInputBorder(),
),
validator: (value) {
if (value == null || value.isEmpty) {
return 'Please confirm your password';
}
if (value != passwordController.text) {
return 'Passwords do not match';
}
return null;
},
),
const SizedBox(height: 10),
CheckboxListTile(
contentPadding: EdgeInsets.zero,
title: const Text(
'I accept the Terms and Conditions',
),
value: acceptedTerms,
onChanged: (value) {
setState(() {
acceptedTerms = value ?? false;
});
},
),
const SizedBox(height: 10),
ElevatedButton(
onPressed: submit,
child: const Text('Create Account'),
),
],
),
),
),
);
}
}
32. Validation Error Messages
Good validation messages should clearly explain what the user needs to fix.
| Bad Message | Better Message |
|---|
| Invalid | Enter a valid email address |
| Error | Password must contain at least 8 characters |
| Required | Email address is required |
| Wrong | Passwords do not match |
| Not valid | Enter a valid 10-digit phone number |
33. Client-Side and Server-Side Validation
Client-Side Validation
Client-side validation happens inside the Flutter application before the request is sent to a server.
- Provides quick feedback.
- Improves user experience.
- Prevents obvious invalid submissions.
- Reduces unnecessary requests.
Server-Side Validation
Server-side validation happens on the backend. It is essential because client-side validation can be bypassed.
- Never trust client-side validation alone.
- Validate incoming API data on the server.
- Apply authentication and authorization rules on the server.
- Validate business rules before saving data.
34. Common Mistakes in Flutter Form Validation
- Forgetting to assign the
GlobalKey to the Form.
- Calling
validate() without checking its return value.
- Returning an error message even when the input is valid.
- Forgetting to return
null for valid input.
- Using unclear validation messages.
- Creating a new
GlobalKey inside the build() method.
- Forgetting to dispose
TextEditingController objects.
- Using only client-side validation for security.
- Allowing whitespace-only input.
- Not handling loading or duplicate submissions.
35. Best Practices for Form Validation
- Use
Form to group related fields.
- Use
GlobalKey when convenient for accessing form state.
- Keep validation rules simple and readable.
- Use reusable validator functions for repeated rules.
- Display clear and actionable error messages.
- Use appropriate keyboard types such as email and phone keyboards.
- Use
autovalidateMode carefully so validation does not become distracting.
- Dispose controllers and focus nodes in
dispose().
- Validate the form before submitting data.
- Perform server-side validation as well.
- Disable or guard against duplicate submissions while an API request is running.
- Use scrollable layouts for long forms.
- Keep sensitive information such as passwords protected and never log passwords.
36. Practical Exercise
Create a Flutter registration form containing the following fields:
- Full Name
- Email Address
- Phone Number
- Password
- Confirm Password
- Age
- Gender
- Country
- Terms and Conditions checkbox
Apply the following validation rules:
- Name should not be empty.
- Email should follow a valid email format.
- Phone number should contain the required number of digits.
- Password should contain at least 8 characters.
- Confirm Password should match Password.
- Age should be a valid number and meet the application's minimum-age requirement.
- Gender should be selected.
- Country should be selected.
- Terms and Conditions must be accepted.
37. Interview Questions
Q1. What is form validation in Flutter?
Form validation is the process of checking whether user-entered data satisfies predefined rules before the application processes or submits it.
Q2. What is the purpose of the Form widget?
The Form widget groups form fields and provides access to operations such as validation, saving, and resetting.
Q3. What is GlobalKey used for?
It provides access to the state of a specific form, allowing methods such as validate(), save(), and reset() to be called.
Q4. What does validator return?
The validator returns an error message when the input is invalid and null when the input is valid.
Q5. What does validate() return?
FormState.validate() returns true when the descendant form fields have no validation errors and false when validation errors exist.
Q6. What is the difference between TextField and TextFormField?
TextField provides general text input, while TextFormField integrates text input with FormField functionality and can participate in form validation.
Q7. Why should TextEditingController be disposed?
A controller manages resources associated with text editing and should be disposed when it is no longer needed.
38. Quick Revision
Form groups multiple fields.
GlobalKey can access form state.
TextFormField is commonly used for validated text input.
validator contains field validation logic.
- Return an error message for invalid input.
- Return
null for valid input.
validate() checks all descendant form fields.
save() triggers onSaved callbacks.
reset() restores fields to their initial state.
AutovalidateMode controls automatic validation.
- Client-side validation improves UX but does not replace server-side validation.
- Dispose controllers and focus nodes when they are no longer needed.
39. Official Flutter Resources
40. JustAcademy Flutter Training Resources
Key Takeaways
Flutter provides a structured approach to form validation using Form, FormField, TextFormField, validators, and FormState. The most common workflow is to create a GlobalKey, attach it to a Form, define validation rules through validator, and call validate() before processing the submitted data.